﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Citrix.XaAdminMobile.Util;
using Citrix.Cmp;
using System.Reflection;
using System.Windows.Input;
using System.Runtime.InteropServices;
using System.Windows;

namespace Citrix.XaAdminMobile
{
    /// <summary>
    /// One of the complexities with using CMP SDK currently is keeping the CMP state on the
    /// client in sync with what you want, when you have multiple CMP apps running in a session.
    /// Currently you have to manage this manually which is what this class is for. In future
    /// versions of CMP we plan to provide builtin syncing functionality that takes care of this
    /// for you.
    /// </summary>
    internal class CmpSynchroniser : IDisposable
    {
        #region Pinvoke types/definitions

        private delegate void WinEventDelegate(
            IntPtr hWinEventHook,
            uint eventType,
            IntPtr hwnd,
            int idObject,
            int idChild,
            uint eventThread,
            uint eventTime);

        [DllImport("user32.dll")]
        private static extern IntPtr SetWinEventHook(
            uint eventMin,
            uint eventMax,
            IntPtr hmodWinEventProc,
            WinEventDelegate winEventProc,
            uint idProcess,
            uint idThread,
            uint flags);

        [DllImport("user32.dll")]
        private static extern bool UnhookWinEvent(IntPtr hWinEventHook);

        [DllImport("kernel32.dll")]
        private static extern uint GetCurrentProcessId();

        [DllImport("user32.dll")]
        private static extern IntPtr GetForegroundWindow();

        [DllImport("user32.dll")]
        private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);

        private const uint EVENT_SYSTEM_DESKTOPSWITCH = 0x00000020;
        private const uint EVENT_SYSTEM_FOREGROUND = 3;
        private const uint WINEVENT_OUTOFCONTEXT = 0;
        private const uint WINEVENT_SKIPOWNTHREAD = 1;

        #endregion

        /// <summary>
        /// Our windows hook event handler. We hook desktop switch and foreground window change
        /// events. We must hold a ref to the delegate so it doesn't get GC'd.
        /// </summary>
        private WinEventDelegate winEventHandler;

        /// <summary>
        /// The windows hook handle.
        /// </summary>
        private IntPtr winEventHookHandle = IntPtr.Zero;

        /// <summary>
        /// Used to sync internal state.
        /// </summary>
        private object stateLock = new object();

        /// <summary>
        /// Whether or not the application is asserting ownership of CMP in the current session.
        /// </summary>
        private bool haveCmpControl = true;

        /// <summary>
        /// The current desired back button target.
        /// </summary>
        private ButtonTarget backButtonTarget = ButtonTarget.ClientDevice;

        /// <summary>
        /// A reference back to the CMP API.
        /// </summary>
        private readonly CmpApi cmpApi;

        /// <summary>
        /// Ref back to the app.
        /// </summary>
        private readonly App theApp;

        /// <summary>
        /// This timer is used to retry setting CMP state.
        /// </summary>
        private System.Timers.Timer retryTimer;

        /// <summary>
        /// Class preview mouse down handler.
        /// </summary>
        private MouseButtonEventHandler mouseDownHandler;

        /// <summary>
        /// Initialisation ctor.
        /// </summary>
        /// <param name="app">Main app class reference.</param>
        /// <param name="cmpApiRef">CMP API.</param>
        /// <exception cref="ArgumentNullException">Thrown if the API ref is null.</exception>
        public CmpSynchroniser(App app, CmpApi cmpApiRef)
        {
            if (null == app)
            {
                throw new ArgumentNullException("app");
            }
            if (null == cmpApiRef)
            {
                throw new ArgumentNullException("cmpApiRef");
            }
            app.Activated += new EventHandler(Application_Activated);
            app.Deactivated += new EventHandler(Application_Deactivated);
            this.theApp = app;
            this.cmpApi = cmpApiRef;

            this.mouseDownHandler = new MouseButtonEventHandler(MouseButtonEventHandler);
            EventManager.RegisterClassHandler(typeof(UIElement), UIElement.PreviewMouseDownEvent, this.mouseDownHandler);

            // Note: no pinning required for the delegate, it's done automatically.
            this.winEventHandler = new WinEventDelegate(WinEventCallback);
            this.winEventHookHandle = SetWinEventHook(
                EVENT_SYSTEM_FOREGROUND,
                EVENT_SYSTEM_DESKTOPSWITCH,
                IntPtr.Zero,
                this.winEventHandler,
                0,
                0,
                WINEVENT_OUTOFCONTEXT);

            this.retryTimer = new System.Timers.Timer();
            this.retryTimer.AutoReset = false;
            this.retryTimer.Interval = 200; // 200ms
            this.retryTimer.Elapsed += (o, e) => ApplyStateToCmp();

            this.cmpApi.SessionManager.SessionConnected += new EventHandler<SessionConnectedArgs>(SessionManager_SessionConnected);
            this.cmpApi.Input.TouchInputModeChanged += new EventHandler<TouchInputModeChangedArgs>(Input_TouchInputModeChanged);
            this.cmpApi.Input.ButtonRedirectionChanged += new EventHandler<ButtonRedirectionArgs>(Input_ButtonRedirectionChanged);
            this.cmpApi.ClientControls.VisibilityChanged += new EventHandler<ClientControlStateChangedArgs>(ClientControls_VisibilityChanged);
            if (this.cmpApi.SessionManager.IsCmpAvailable)
            {
                ApplyStateToCmp();
            }
        }

        /// <summary>
        /// Finalizer
        /// </summary>
        ~CmpSynchroniser()
        {
            OnDispose(false);
        }

        /// <summary>
        /// Used to set/get whether back button presses should be routed up to the server
        /// application, or handled locally on the client device.
        /// </summary>
        public ButtonTarget BackButtonTarget
        {
            get
            {
                lock (this.stateLock)
                {
                    return this.backButtonTarget;
                }
            }
            set
            {
                if (value != ButtonTarget.Unsupported)
                {
                    bool changed = false;
                    lock (this.stateLock)
                    {
                        if (this.backButtonTarget != value)
                        {
                            changed = true;
                            this.backButtonTarget = value;
                        }
                    }
                    if (changed)
                    {
                        SyncBackButtonRedirection();
                    }
                }
            }
        }

        /// <summary>
        /// This property controls whether this application is asserting ownership of the CMP
        /// features of the client device in this session. If you want to support multiple apps
        /// in the same session all accessing CMP, you should ensure only one app at a time sets
        /// CMP state. Generally this should be the app that currently has focus.
        /// 
        /// When this is set to false any changes made to CMP state are cached locally and not
        /// sent to the client device. When set back to true the cached values are compared with
        /// the current client state, and the client is requested to sync with our values.
        /// </summary>
        public bool HaveCmpControl
        {
            get
            {
                lock (this.stateLock)
                {
                    return this.haveCmpControl;
                }
            }
            private set
            {
                bool regainedControl = false;
                lock (this.stateLock)
                {
                    if (this.haveCmpControl != value)
                    {
                        this.haveCmpControl = value;
                        regainedControl = value;
                        FTrace.Trace5(MethodBase.GetCurrentMethod(), "Is activated: {0}", value);
                    }
                }

                if (regainedControl)
                {
                    ApplyStateToCmp();
                }
            }
        }

        /// <summary>
        /// App activate handler used to gain control of CMP.
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void Application_Activated(object sender, EventArgs e)
        {
            this.HaveCmpControl = true;
        }

        /// <summary>
        /// App deactivate handler used to release control of CMP.
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void Application_Deactivated(object sender, EventArgs e)
        {
            this.HaveCmpControl = false;
        }

        /// <summary>
        /// Session connect handler to set required CMP state on the client.
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void SessionManager_SessionConnected(object sender, SessionConnectedArgs e)
        {
            if (this.HaveCmpControl && this.cmpApi.SessionManager.IsCmpAvailable)
            {
                ApplyStateToCmp();
            }
        }

        /// <summary>
        /// Syncs up the client CMP state with the target state that GoldenGate wants.
        /// </summary>
        private void ApplyStateToCmp()
        {
            this.retryTimer.Stop();

            bool syncedInput = SyncInputMode();
            bool syncedBackButton = SyncBackButtonRedirection();
            bool syncedClientControls = SyncClientControlsVisibility();

            //
            // If any of our CMP syncing failed, queue up a retry to get ourselves back in sync.
            //
            if (!syncedInput || !syncedBackButton || !syncedClientControls)
            {
                if (this.HaveCmpControl)
                {
                    FTrace.Trace1(MethodBase.GetCurrentMethod(), "Queuing retry for CMP state sync.");
                    this.retryTimer.Start();
                }
            }
        }

        /// <summary>
        /// Syncs the cached back button redirection state with the client.
        /// </summary>
        /// <returns>True if we synced the client into the state we want, false if we attempted
        /// a sync but there was a sync error.</returns>
        private bool SyncBackButtonRedirection()
        {
            bool synced = false;

            if (this.HaveCmpControl && this.cmpApi.SessionManager.IsCmpAvailable &&
                this.cmpApi.Input.IsButtonRedirectionSupported(ButtonId.Back))
            {
                try
                {
                    // NOTE: we always just bash to the desired target, we don't guard/skip by
                    // checking GetButtonTarget. This is mainly to work around a temp bug where
                    // the GetButtonTarget API returns a spurious error on first use.
                    ButtonTarget target = this.BackButtonTarget;
                    FTrace.Debug(MethodBase.GetCurrentMethod(), "Back button target: {0}", target);
                    this.cmpApi.Input.SetButtonTarget(ButtonId.Back, target);
                    synced = true;
                }
                catch (CmpCallFailedException ex)
                {
                    if (ex.CmpStatus.Error == CmpError.CapabilityNotSupported)
                    {
                        // We return success if the client simply doesn't support what we're
                        // trying to do.
                        synced = true;
                    }
                    else
                    {
                        FTrace.Except(MethodBase.GetCurrentMethod(), ex);
                    }
                }
                catch (CmpException ex)
                {
                    FTrace.Except(MethodBase.GetCurrentMethod(), ex);
                }
            }
            else
            {
                synced = true;
            }

            return synced;
        }

        /// <summary>
        /// Syncs the desired client control visibility state. We always want the client
        /// controls disabled when supported by the client.
        /// </summary>
        /// <returns>True if we synced the client into the state we want, false if we attempted
        /// a sync but there was a sync error.</returns>
        private bool SyncClientControlsVisibility()
        {
            bool synced = false;

            if (this.HaveCmpControl && this.cmpApi.SessionManager.IsCmpAvailable &&
                this.cmpApi.ClientControls.CanControl)
            {
                try
                {
                    bool enabled = this.cmpApi.ClientControls.GetClientControlsStatus();
                    if (enabled)
                    {
                        this.cmpApi.ClientControls.Disable();
                    }
                    synced = true;
                }
                catch (CmpCallFailedException ex)
                {
                    if (ex.CmpStatus.Error == CmpError.CapabilityNotSupported)
                    {
                        // We return success if the client simply doesn't support what we're
                        // trying to do.
                        synced = true;
                    }
                    else
                    {
                        FTrace.Except(MethodBase.GetCurrentMethod(), ex);
                    }
                }
                catch (CmpException ex)
                {
                    FTrace.Except(MethodBase.GetCurrentMethod(), ex);
                }
            }
            else
            {
                synced = true;
            }

            return synced;
        }

        /// <summary>
        /// Syncs the desired touch input mode. We always want the raw option, not the legacy
        /// panning or scrolling modes.
        /// </summary>
        /// <returns>True if we synced the client into the state we want, false if we attempted
        /// a sync but there was a sync error.</returns>
        private bool SyncInputMode()
        {
            bool synced = false;

            if (this.HaveCmpControl && this.cmpApi.SessionManager.IsCmpAvailable &&
                this.cmpApi.Input.IsTouchInputModeSupported(TouchInputMode.RawMouseInput))
            {
                try
                {
                    var inputMode = this.cmpApi.Input.GetTouchInputMode();
                    FTrace.Trace9(MethodBase.GetCurrentMethod(), "Existing mode is: {0}", inputMode);
                    if (inputMode != TouchInputMode.RawMouseInput)
                    {
                        this.cmpApi.Input.SetTouchInputMode(TouchInputMode.RawMouseInput);
                        FTrace.Debug(MethodBase.GetCurrentMethod(), "Set input mode success.");
                    }
                    synced = true;
                }

                catch (CmpCallFailedException ex)
                {
                    if (ex.CmpStatus.Error == CmpError.CapabilityNotSupported)
                    {
                        // We return success if the client simply doesn't support what we're
                        // trying to do.
                        synced = true;
                    }
                    else
                    {
                        FTrace.Except(MethodBase.GetCurrentMethod(), ex);
                    }
                }
                catch (CmpException ex)
                {
                    FTrace.Except(MethodBase.GetCurrentMethod(), ex);
                }
            }
            else
            {
                synced = true;
            }

            return synced;
        }

        /// <summary>
        /// Global mouse down handler. We use this as a sledge hammer to work around bugs where
        /// we wouldn't always get notified of regaining focus. When Alcatraz is displayed at
        /// session startup, for some reason when it is dismissed and GoldenGate is redisplayed
        /// it is not getting notified that it is the foreground window. Instead the last window
        /// we see getting the foreground is generally wfshell.
        /// 
        /// So we handle mouse down input inside the app, and use it as the trigger to recheck
        /// whether we are now the foreground. This allows us to reliably regain CMP control. If
        /// we didn't do this we wouldn't resync our CMP state and the app could misbehave.
        /// </summary>
        /// <param name="sender">Not used.</param>
        /// <param name="e">Not used.</param>
        private void MouseButtonEventHandler(Object sender, MouseButtonEventArgs e)
        {
            if (!this.HaveCmpControl)
            {
                FTrace.Trace9(MethodBase.GetCurrentMethod(), "Rechecking foreground control.");
                this.HaveCmpControl = IsForeground();
            }
        }

        /// <summary>
        /// Check for other CMP apps re-enabling the onscreen client controls.
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void ClientControls_VisibilityChanged(object sender, ClientControlStateChangedArgs e)
        {
            if (this.cmpApi.ClientControls.CanControl && e.OnScreenControlsVisible)
            {
                FTrace.Trace5(MethodBase.GetCurrentMethod(),
                    "Another CMP enabled app changed the on-screen controls visibility. Re-hiding...");
                ApplyStateToCmp();
            }
        }

        /// <summary>
        /// Handler for button redirection changes. We track changes in case another CMP enabled
        /// app changes the redirection underneath us so we can change it back.
        /// </summary>
        /// <param name="sender">Not used.</param>
        /// <param name="e">The button redirection arguments.</param>
        private void Input_ButtonRedirectionChanged(object sender, ButtonRedirectionArgs e)
        {
            if (this.HaveCmpControl && this.cmpApi.SessionManager.IsCmpAvailable &&
                e.ButtonId == ButtonId.Back &&
                e.NewButtonTarget != this.BackButtonTarget)
            {
                FTrace.Trace5(MethodBase.GetCurrentMethod(),
                    "Another CMP enabled app changed the back button redirection while we have control! Resetting redirection.");
                ApplyStateToCmp();
            }
        }

        /// <summary>
        /// Handler for input mode changes. We track changes in case another CMP enabled app
        /// changes the input mode underneath us (so we can change it back). The main culprit is
        /// PinPad which uses CMP without using the focus based control like we do.
        /// </summary>
        /// <param name="sender">Not used.</param>
        /// <param name="e">The new touch input mode.</param>
        private void Input_TouchInputModeChanged(object sender, TouchInputModeChangedArgs e)
        {
            if (this.HaveCmpControl && this.cmpApi.SessionManager.IsCmpAvailable &&
                e.NewMode != TouchInputMode.RawMouseInput)
            {
                FTrace.Trace5(MethodBase.GetCurrentMethod(),
                    "Another CMP enabled app changed the input mode while we have control! Resetting input mode.");
                ApplyStateToCmp();
            }
        }

        /// <summary>
        /// Windows event hook handler. We check for desktop switches and the foreground window
        /// changing. Either of these events can indicate that we have gained or lost control of
        /// CMP since we can only call it when we are the foreground window.
        /// </summary>
        /// <param name="hWinEventHook">Not used.</param>
        /// <param name="eventType">The event type.</param>
        /// <param name="hwnd">Not used.</param>
        /// <param name="idObject">Not used.</param>
        /// <param name="idChild">Not used.</param>
        /// <param name="eventThread">Not used.</param>
        /// <param name="eventTime">Not used.</param>
        /// <remarks>
        /// The WPF application Activated event doesn't appear to always fire reliably at
        /// session start/reconnection after PinPad is dismissed. So we are checking for other
        /// triggers to detect when we regain focus so we can set the CMP state back to what we
        /// require.
        /// </remarks>
        private void WinEventCallback(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint eventThread, uint eventTime)
        {
            if (eventType == EVENT_SYSTEM_DESKTOPSWITCH)
            {
                FTrace.Trace5(MethodBase.GetCurrentMethod(), "Desktop switch detected.");
                this.HaveCmpControl = IsForeground();
            }
            else if (eventType == EVENT_SYSTEM_FOREGROUND)
            {
                FTrace.Trace9(MethodBase.GetCurrentMethod(), "Foreground window changed.");
                this.HaveCmpControl = IsForeground();
            }
        }

        /// <summary>
        /// Checks whether the application is currently in the foreground.
        /// </summary>
        /// <returns>True if this is the foreground app, false if it isn't.</returns>
        private bool IsForeground()
        {
            bool result = false;
            IntPtr foreground = GetForegroundWindow();
            if (IntPtr.Zero != foreground)
            {
                uint ourProcId = GetCurrentProcessId();
                uint foregroundProcId;
                uint threadId = GetWindowThreadProcessId(foreground, out foregroundProcId);

                FTrace.Trace9(MethodBase.GetCurrentMethod(),
                    "Is foreground:{0}, Foreground ID:{1}, Our ID:{2}",
                    ourProcId == foregroundProcId, foregroundProcId, ourProcId);
                result = (ourProcId == foregroundProcId);
            }
            return result;
        }

        #region IDisposable Members

        /// <summary>
        /// Dispose handler
        /// </summary>
        public void Dispose()
        {
            OnDispose(true);
            GC.SuppressFinalize(this);
        }

        /// <summary>
        /// Dispose implementation.
        /// </summary>
        private void OnDispose(bool cleanupMangedAndUnmanaged)
        {
            if (cleanupMangedAndUnmanaged)
            {
                if (null != this.retryTimer)
                {
                    this.retryTimer.Dispose();
                }
            }
            if (IntPtr.Zero != this.winEventHookHandle)
            {
                if (!UnhookWinEvent(this.winEventHookHandle))
                {
                    FTrace.Trace1(MethodBase.GetCurrentMethod(), "Failed to unhook switch desktop windows event");
                }
                this.winEventHookHandle = IntPtr.Zero;
            }
        }

        #endregion
    }
}
